前言 今天要來爬的是股權分散表,用來統計大股東的持有比例, 網路上其實也有一些不錯的資源,像是: 神秘金字塔 ,針對400張、1000張以上的給出持股比例。
但這邊的目標是能做出「自動通知」或是「篩選」哪些股票是大戶持股正在增加工具,為此目標還是必須得將資料爬下來。那馬上開始吧!
觀察網站 我們可以從 台灣集中保管結算所 中
資料查詢 > 集保戶股權分散表
這裡可以查詢我們要的資訊。
不過這次我打算從 集保戶股權分散表查詢 來爬取, 這應該是比較舊的網站,不過資料是一樣的
進入網站,首先我們需要先知道可以爬取的日期,從F12觀察工具可以看到如下:
這個就是我們可以爬取的所有日期資訊,是JSON格式,處理起來還是比較容易的!
接下來是資料部分,查詢0050,觀察Request
這裡可以得到我們發送請求的URL、以及Form-Data 這樣就可以開始程式的部分了!
爬取股權分散表資訊 爬取HTML 首先先爬取目前可以用的日期,程式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public async Task<IEnumerable<string >> GetDateStringListByTDCCAsync(){ using (var client = _clientFactory.CreateClient()) { var response = await client.PostAsync( "https://www.tdcc.com.tw/smWeb/QryStockAjax.do" , new FormUrlEncodedContent( new [] { new KeyValuePair<string ,string >("REQ_OPR" ,"qrySelScaDates" ) } )); var result = await response.Content.ReadAsStringAsync(); if (response.StatusCode != System.Net.HttpStatusCode.OK) throw new PlatformNotSupportedException($"目前無法爬取集保戶股權日期資料...,{response.StatusCode} ,{result} " ); return JsonSerializer.Deserialize<List<string >>(result); } }
從剛剛查詢0050的Request得知,我們可以用股票代號、日期,來爬取整份HTML資料,程式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public async Task<string > GetStockHolderHtmlByTDCCAsync (string stock_id, string dateString ) { using (var client = _clientFactory.CreateClient()) { var response = await client.PostAsync( "https://www.tdcc.com.tw/smWeb/QryStockAjax.do" , new FormUrlEncodedContent( new [] { new KeyValuePair<string ,string >("scaDates" , dateString), new KeyValuePair<string ,string >("scaDate" , dateString), new KeyValuePair<string ,string >("SqlMethod" , "StockNo" ), new KeyValuePair<string ,string >("StockNo" , stock_id), new KeyValuePair<string ,string >("radioStockNo" , stock_id), new KeyValuePair<string ,string >("StockName" , "" ), new KeyValuePair<string ,string >("REQ_OPR" , "SELECT" ), new KeyValuePair<string ,string >("clkStockNo" , stock_id), new KeyValuePair<string ,string >("clkStockName" , "" ) } )); var result = await response.Content.ReadAsStringAsync(); if (response.StatusCode != System.Net.HttpStatusCode.OK) throw new PlatformNotSupportedException($"目前無法爬取集保戶股權資料...,{response.StatusCode} ,{result} " ); return result; } }
這裡改用 IHttpClientFactory 來取得 HttpClient,主要原因是: 每個要求具現化 HttpClient 類別將會在負載過重時耗盡可用的通訊端數目 想要了解更多可以參考:使用 IHttpClientFactory 實現彈性 HTTP 要求 使用的 HttpClient 錯誤的博客文章,它破壞了您的軟體
分析HTML 接下來要分析一下HTML,直接從Chrome F12工具中觀察, 可以發現回傳的資料是包在 class="mt"
的 table
裡面
不過整個頁面中其實有兩個 class="mt"
的 table
,我們要的資料是第二個 一樣用 HtmlAgilityPack 這個套件來做解析,安裝的部分可以回去看看這篇 => 用 C# .NET Core 爬取每季財報
首先先建立儲存資料庫的 Model
1 2 3 4 5 6 7 8 9 10 11 12 [Table("StockHolder" ) ] public class StockHolder { [ExplicitKey ] public string stock_id { get ; set ; } [ExplicitKey ] public string date_string { get ; set ; } [ExplicitKey ] public string holder_level { get ; set ; } public int people_count { get ; set ; } public string stock_holder_count { get ; set ; } }
接下來是解析HTML的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public IEnumerable<StockHolder> ParseStockHolderHtml (string html, string stock_id, string dateString ) { HtmlDocument doc = new HtmlDocument(); doc.LoadHtml(html); var tableNodes = doc.DocumentNode.SelectNodes("//table[@class=\"mt\"]" ); var trNodes = tableNodes[1 ].SelectNodes("./tbody/tr" ); Dictionary<string , int > headerDictionary = new Dictionary<string , int >(); List<StockHolder> stockHolderList = new List<StockHolder>(); for (int trIndex=0 ; trIndex < trNodes.Count; trIndex++) { if (trIndex == 0 ) { var tdNodes = trNodes[trIndex].SelectNodes("./td" ); for (int tdIndex=0 ; tdIndex < tdNodes.Count; tdIndex++) { headerDictionary.Add(tdNodes[tdIndex].InnerText.Replace(" " ,"" ), tdIndex); } } else { var tdNodes = trNodes[trIndex].SelectNodes("./td" ); if (tdNodes[0 ].InnerText == "無此資料" ) throw new Exception("無此資料" ); string level; if (tdNodes[headerDictionary["持股/單位數分級" ]].InnerText.Replace(" " ,"" ) == "合計" ) level = "total" ; else level = tdNodes[headerDictionary["序" ]].InnerText; if (level == "total" || Convert.ToInt32(level) <= 15 ) { yield return new StockHolder ( ) { stock_id = stock_id, date_string = dateString, holder_level = level, people_count = Convert.ToInt32(tdNodes[headerDictionary["人數" ]].InnerText.Replace("," ,"" )), stock_holder_count = tdNodes[headerDictionary["股數/單位數" ]].InnerText.Replace("," ,"" ), }; } } } }
存入資料庫 最後是存入資料庫的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class StockHolderRepository { private readonly SqlConnection _conn; private readonly ILogger<StockHolderRepository> _logger; public StockHolderRepository (ILogger<StockHolderRepository> logger, SqlConnection conn ) { _logger = logger; _conn = conn; } public void Insert (IEnumerable<StockHolder> stockHolderList ) { try { using (var scope = new TransactionScope()) { foreach (var stockHolder in stockHolderList) { _conn.Insert(stockHolder); } scope.Complete(); } } catch (Exception ex) { _logger.LogError(ex.Message); } } public bool IsExist (string stockId, string dateString ) { return _conn.ExecuteScalar<bool >( "select count(1) from StockHolder where stock_id=@stockId and date_string=@dateString" , new { stockId, dateString} ); } }
整理成排程 接下來整理成一個讓排成呼叫的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 public async Task ExecuteAsync (string stock_id, string dateString ) { try { var html = await GetStockHolderHtmlByTDCCAsync(stock_id, dateString); var stockHolderList = ParseStockHolderHtml(html, stock_id, dateString); _stockHolderRepository.Insert(stockHolderList); } catch (Exception ex) { _logger.LogWarning($"StockHolderClawer error\n{ex.Message} " ); } }
最後一樣是使用 Coravel 這個套件來做排程,想了解使用方式可以參考這篇 用 C# .NET Core 自動爬取台股每日股價
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class StockHolderClawerSchedule : IInvocable { private StockHolderClawer _stockHolderClawer; private StockHolderRepository _stockHolderRepository; private StockInfoRepository _stockInfoRepository; private readonly ILogger<StockHolderClawerSchedule> _logger; public StockHolderClawerSchedule (ILogger<StockHolderClawerSchedule> logger, StockHolderClawer stockHolderClawer, StockHolderRepository stockHolderRepository, StockInfoRepository stockInfoRepository ) { _stockHolderRepository = stockHolderRepository; _stockInfoRepository = stockInfoRepository; _stockHolderClawer = stockHolderClawer; _logger = logger; } public async Task Invoke ( ) { _logger.LogInformation("StockHolderClawerSchedule Start" ); var stockIdList = _stockInfoRepository.GetAllStockIdByType("上市" ); var dateStringList = (await _stockHolderClawer.GetDateStringListByTDCCAsync()).ToList(); foreach (var dateString in dateStringList) { foreach (var stockId in stockIdList) { if (!_stockHolderRepository.IsExist(stockId, dateString)) { _logger.LogInformation($"StockHolderClawer Execute: {stockId} , {dateString} " ); await _stockHolderClawer.ExecuteAsync(stockId, dateString); Thread.Sleep(6000 ); } } } } }
最後註冊排程跑的時間:
1 2 3 scheduler .Schedule<StockHolderClawerSchedule>() .Cron("41 17 * * 5" );
搞定收工!
心得 其實集保所每週會公布一份總表,每週五可以改從總表抓取資料,應該會快很多! 可惜的是沒有歷史資訊的總表可以查,所以才採用一檔一檔爬的方式! 這樣確實得花非常多的時間,目前還沒有找到比較好的解決方法。 但也因此發現了HttpClient的小問題,也算是不錯的收穫吧!
↓↓↓ 如果喜歡我的文章,可以幫我按個Like! ↓↓↓
>> 或者,請我喝杯咖啡,這樣我會更有動力唷! <<<
街口支付
街口帳號: 901061546